﻿function WebGLBindingStates(gl, extensions, attributes, capabilities) {
  const maxVertexAttributes = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);

  const extension = capabilities.isWebGL2 ? null : extensions.get('OES_vertex_array_object');
  const vaoAvailable = capabilities.isWebGL2 || extension !== null;

  const bindingStates = {};

  const defaultState = createBindingState(null);
  let currentState = defaultState;

  function setup(object, material, program, geometry, index) {
    let updateBuffers = false;

    if (vaoAvailable) {
      const state = getBindingState(geometry, program, material);

      if (currentState !== state) {
        currentState = state;
        bindVertexArrayObject(currentState.object);
      }

      updateBuffers = needsUpdate(geometry, index);

      if (updateBuffers) saveCache(geometry, index);
    } else {
      const wireframe = material.wireframe === true;

      if (
        currentState.geometry !== geometry.id ||
        currentState.program !== program.id ||
        currentState.wireframe !== wireframe
      ) {
        currentState.geometry = geometry.id;
        currentState.program = program.id;
        currentState.wireframe = wireframe;

        updateBuffers = true;
      }
    }

    if (object.isInstancedMesh === true) {
      updateBuffers = true;
    }

    if (index !== null) {
      attributes.update(index, gl.ELEMENT_ARRAY_BUFFER);
    }

    if (updateBuffers) {
      setupVertexAttributes(object, material, program, geometry);

      if (index !== null) {
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, attributes.get(index).buffer);
      }
    }
  }

  function createVertexArrayObject() {
    if (capabilities.isWebGL2) return gl.createVertexArray();

    return extension.createVertexArrayOES();
  }

  function bindVertexArrayObject(vao) {
    if (capabilities.isWebGL2) return gl.bindVertexArray(vao);

    return extension.bindVertexArrayOES(vao);
  }

  function deleteVertexArrayObject(vao) {
    if (capabilities.isWebGL2) return gl.deleteVertexArray(vao);

    return extension.deleteVertexArrayOES(vao);
  }

  function getBindingState(geometry, program, material) {
    const wireframe = material.wireframe === true;

    let programMap = bindingStates[geometry.id];

    if (programMap === undefined) {
      programMap = {};
      bindingStates[geometry.id] = programMap;
    }

    let stateMap = programMap[program.id];

    if (stateMap === undefined) {
      stateMap = {};
      programMap[program.id] = stateMap;
    }

    let state = stateMap[wireframe];

    if (state === undefined) {
      state = createBindingState(createVertexArrayObject());
      stateMap[wireframe] = state;
    }

    return state;
  }

  function createBindingState(vao) {
    const newAttributes = [];
    const enabledAttributes = [];
    const attributeDivisors = [];

    for (let i = 0; i < maxVertexAttributes; i++) {
      newAttributes[i] = 0;
      enabledAttributes[i] = 0;
      attributeDivisors[i] = 0;
    }

    return {
      // for backward compatibility on non-VAO support browser
      geometry: null,
      program: null,
      wireframe: false,

      newAttributes,
      enabledAttributes,
      attributeDivisors,
      object: vao,
      attributes: {},
      index: null,
    };
  }

  function needsUpdate(geometry, index) {
    const cachedAttributes = currentState.attributes;
    const geometryAttributes = geometry.attributes;

    let attributesNum = 0;

    for (const key in geometryAttributes) {
      const cachedAttribute = cachedAttributes[key];
      const geometryAttribute = geometryAttributes[key];

      if (cachedAttribute === undefined) return true;

      if (cachedAttribute.attribute !== geometryAttribute) return true;

      if (cachedAttribute.data !== geometryAttribute.data) return true;

      attributesNum++;
    }

    if (currentState.attributesNum !== attributesNum) return true;

    if (currentState.index !== index) return true;

    return false;
  }

  function saveCache(geometry, index) {
    const cache = {};
    const attributes = geometry.attributes;
    let attributesNum = 0;

    for (const key in attributes) {
      const attribute = attributes[key];

      const data = {};
      data.attribute = attribute;

      if (attribute.data) {
        data.data = attribute.data;
      }

      cache[key] = data;

      attributesNum++;
    }

    currentState.attributes = cache;
    currentState.attributesNum = attributesNum;

    currentState.index = index;
  }

  function initAttributes() {
    const newAttributes = currentState.newAttributes;

    for (let i = 0, il = newAttributes.length; i < il; i++) {
      newAttributes[i] = 0;
    }
  }

  function enableAttribute(attribute) {
    enableAttributeAndDivisor(attribute, 0);
  }

  function enableAttributeAndDivisor(attribute, meshPerAttribute) {
    const newAttributes = currentState.newAttributes;
    const enabledAttributes = currentState.enabledAttributes;
    const attributeDivisors = currentState.attributeDivisors;

    newAttributes[attribute] = 1;

    if (enabledAttributes[attribute] === 0) {
      gl.enableVertexAttribArray(attribute);
      enabledAttributes[attribute] = 1;
    }

    if (attributeDivisors[attribute] !== meshPerAttribute) {
      const extension = capabilities.isWebGL2 ? gl : extensions.get('ANGLE_instanced_arrays');

      extension[capabilities.isWebGL2 ? 'vertexAttribDivisor' : 'vertexAttribDivisorANGLE'](
        attribute,
        meshPerAttribute,
      );
      attributeDivisors[attribute] = meshPerAttribute;
    }
  }

  function disableUnusedAttributes() {
    const newAttributes = currentState.newAttributes;
    const enabledAttributes = currentState.enabledAttributes;

    for (let i = 0, il = enabledAttributes.length; i < il; i++) {
      if (enabledAttributes[i] !== newAttributes[i]) {
        gl.disableVertexAttribArray(i);
        enabledAttributes[i] = 0;
      }
    }
  }

  function vertexAttribPointer(index, size, type, normalized, stride, offset) {
    if (capabilities.isWebGL2 === true && (type === gl.INT || type === gl.UNSIGNED_INT)) {
      gl.vertexAttribIPointer(index, size, type, stride, offset);
    } else {
      gl.vertexAttribPointer(index, size, type, normalized, stride, offset);
    }
  }

  function setupVertexAttributes(object, material, program, geometry) {
    if (capabilities.isWebGL2 === false && (object.isInstancedMesh || geometry.isInstancedBufferGeometry)) {
      if (extensions.get('ANGLE_instanced_arrays') === null) return;
    }

    initAttributes();

    const geometryAttributes = geometry.attributes;

    const programAttributes = program.getAttributes();

    const materialDefaultAttributeValues = material.defaultAttributeValues;

    for (const name in programAttributes) {
      const programAttribute = programAttributes[name];

      if (programAttribute >= 0) {
        const geometryAttribute = geometryAttributes[name];

        if (geometryAttribute !== undefined) {
          const normalized = geometryAttribute.normalized;
          const size = geometryAttribute.itemSize;

          const attribute = attributes.get(geometryAttribute);

          // TODO Attribute may not be available on context restore

          if (attribute === undefined) continue;

          const buffer = attribute.buffer;
          const type = attribute.type;
          const bytesPerElement = attribute.bytesPerElement;

          if (geometryAttribute.isInterleavedBufferAttribute) {
            const data = geometryAttribute.data;
            const stride = data.stride;
            const offset = geometryAttribute.offset;

            if (data && data.isInstancedInterleavedBuffer) {
              enableAttributeAndDivisor(programAttribute, data.meshPerAttribute);

              if (geometry._maxInstanceCount === undefined) {
                geometry._maxInstanceCount = data.meshPerAttribute * data.count;
              }
            } else {
              enableAttribute(programAttribute);
            }

            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
            vertexAttribPointer(
              programAttribute,
              size,
              type,
              normalized,
              stride * bytesPerElement,
              offset * bytesPerElement,
            );
          } else {
            if (geometryAttribute.isInstancedBufferAttribute) {
              enableAttributeAndDivisor(programAttribute, geometryAttribute.meshPerAttribute);

              if (geometry._maxInstanceCount === undefined) {
                geometry._maxInstanceCount = geometryAttribute.meshPerAttribute * geometryAttribute.count;
              }
            } else {
              enableAttribute(programAttribute);
            }

            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
            vertexAttribPointer(programAttribute, size, type, normalized, 0, 0);
          }
        } else if (name === 'instanceMatrix') {
          const attribute = attributes.get(object.instanceMatrix);

          // TODO Attribute may not be available on context restore

          if (attribute === undefined) continue;

          const buffer = attribute.buffer;
          const type = attribute.type;

          enableAttributeAndDivisor(programAttribute + 0, 1);
          enableAttributeAndDivisor(programAttribute + 1, 1);
          enableAttributeAndDivisor(programAttribute + 2, 1);
          enableAttributeAndDivisor(programAttribute + 3, 1);

          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

          gl.vertexAttribPointer(programAttribute + 0, 4, type, false, 64, 0);
          gl.vertexAttribPointer(programAttribute + 1, 4, type, false, 64, 16);
          gl.vertexAttribPointer(programAttribute + 2, 4, type, false, 64, 32);
          gl.vertexAttribPointer(programAttribute + 3, 4, type, false, 64, 48);
        } else if (name === 'instanceColor') {
          const attribute = attributes.get(object.instanceColor);

          // TODO Attribute may not be available on context restore

          if (attribute === undefined) continue;

          const buffer = attribute.buffer;
          const type = attribute.type;

          enableAttributeAndDivisor(programAttribute, 1);

          gl.bindBuffer(gl.ARRAY_BUFFER, buffer);

          gl.vertexAttribPointer(programAttribute, 3, type, false, 12, 0);
        } else if (materialDefaultAttributeValues !== undefined) {
          const value = materialDefaultAttributeValues[name];

          if (value !== undefined) {
            switch (value.length) {
              case 2:
                gl.vertexAttrib2fv(programAttribute, value);
                break;

              case 3:
                gl.vertexAttrib3fv(programAttribute, value);
                break;

              case 4:
                gl.vertexAttrib4fv(programAttribute, value);
                break;

              default:
                gl.vertexAttrib1fv(programAttribute, value);
            }
          }
        }
      }
    }

    disableUnusedAttributes();
  }

  function dispose() {
    reset();

    for (const geometryId in bindingStates) {
      const programMap = bindingStates[geometryId];

      for (const programId in programMap) {
        const stateMap = programMap[programId];

        for (const wireframe in stateMap) {
          deleteVertexArrayObject(stateMap[wireframe].object);

          delete stateMap[wireframe];
        }

        delete programMap[programId];
      }

      delete bindingStates[geometryId];
    }
  }

  function releaseStatesOfGeometry(geometry) {
    if (bindingStates[geometry.id] === undefined) return;

    const programMap = bindingStates[geometry.id];

    for (const programId in programMap) {
      const stateMap = programMap[programId];

      for (const wireframe in stateMap) {
        deleteVertexArrayObject(stateMap[wireframe].object);

        delete stateMap[wireframe];
      }

      delete programMap[programId];
    }

    delete bindingStates[geometry.id];
  }

  function releaseStatesOfProgram(program) {
    for (const geometryId in bindingStates) {
      const programMap = bindingStates[geometryId];

      if (programMap[program.id] === undefined) continue;

      const stateMap = programMap[program.id];

      for (const wireframe in stateMap) {
        deleteVertexArrayObject(stateMap[wireframe].object);

        delete stateMap[wireframe];
      }

      delete programMap[program.id];
    }
  }

  function reset() {
    resetDefaultState();

    if (currentState === defaultState) return;

    currentState = defaultState;
    bindVertexArrayObject(currentState.object);
  }

  // for backward-compatilibity

  function resetDefaultState() {
    defaultState.geometry = null;
    defaultState.program = null;
    defaultState.wireframe = false;
  }

  return {
    setup,
    reset,
    resetDefaultState,
    dispose,
    releaseStatesOfGeometry,
    releaseStatesOfProgram,

    initAttributes,
    enableAttribute,
    disableUnusedAttributes,
  };
}

export { WebGLBindingStates };
